SPDX-FileCopyrightText: 2026 Zeineb Azzaoui & Charlotte Gigot SPDX-FileCopyrightText: 2026 AlICe laboratory https://alicelab.be
SPDX-License-Identifier: GPL-3.0-or-later
import math
import random
import bmesh
import bpy
from mathutils import VectorMISE EN PLACE DES FONCTIONS nettoyer la scène
def clean_that_up():
bpy.ops.object.select_all(action="SELECT")
bpy.ops.object.delete(use_global=False)
bpy.ops.outliner.orphans_purge()
clean_that_up()fonction cube de taille 10 pour la base
def _basse(_pos, _size, _name):
bpy.ops.mesh.primitive_cube_add(size=10, location=_pos, scale=_size)
bpy.context.object.name = _name
bpy.context.object.data.name = _name + "_mesh"fonction tubes
def _percu(_pos, _ray, _name):
bpy.ops.mesh.primitive_cylinder_add(
radius=_ray, depth=8, location=_pos, scale=(1, 1, 1)
)
bpy.context.object.name = _name
bpy.context.object.data.name = _name + "_mesh"
obj = bpy.context.object
obj.rotation_euler = (0, math.radians(90), 0)fonction sphère pour les nappes
def _nappes(_pos, _size, _name):
bpy.ops.mesh.primitive_uv_sphere_add(radius=1, location=_pos, scale=_size)
bpy.context.object.name = _name
bpy.context.object.data.name = _name + "_mesh"focntion modificateur booléen pour creuser
def boolean_boxes(operand_A, operand_B):
Boolean_boxes = operand_A.modifiers.new(name="my_Boolean", type="BOOLEAN")
Boolean_boxes.operation = "DIFFERENCE"
Boolean_boxes.object = operand_B
Boolean_boxes.solver = "MANIFOLD" # pour creuser plusieurs formes
bpy.context.view_layer.objects.active = operand_A
bpy.ops.object.modifier_apply(modifier=Boolean_boxes.name)
bpy.data.objects.remove(operand_B, do_unlink=True)fonction cercle pour les plateformes
def _plateforme(_pos, _scale, _name):On crée un cercle avec 32 sommets, rempli (NGON)
bpy.ops.mesh.primitive_circle_add(
vertices=32, radius=0.7, fill_type="NGON", location=_pos
)
obj = bpy.context.object
obj.name = _name
obj.data.name = _name + "_mesh"On applique la scale (en X et Y pour faire un ovale ou un cercle parfait)
obj.scale = _scale
return objFonction pour créer un matériau à partir d’un code Hexadecimal RGBA
def creer_materiau_hex(nom, hex_rgba):
mat = bpy.data.materials.new(name=nom)
mat.use_nodes = True
nodes = mat.node_tree.nodes
principled = nodes.get("Principled BSDF")
if principled:Conversion du format #RRGGBBAA en valeurs pour Blender
h = hex_rgba.lstrip("#")
rgba = tuple(int(h[i : i + 2], 16) / 255.0 for i in (0, 2, 4, 6))
principled.inputs["Base Color"].default_value = rgba
return matFonction pour assigner un matériau à toutes les faces d’un objet
def assigner_materiau(obj, mat):
if len(obj.data.materials) == 0:
obj.data.materials.append(mat)
else:
obj.data.materials[0] = matCréer la base de la musique (basse+nappes puis percu)
étape 1 : créer le support = cube de 10*10
_basse((5, 5, 5), (1, 1, 1), "basse")définir la basse comme l’objet à creuser
_obj1 = bpy.data.objects["basse"]attribuer une couleur à la basse
mat_crème = creer_materiau_hex("Mat_crème", "#E7DCC3FF")
assigner_materiau(_obj1, mat_crème)creation des tubes de percussions
for axe_y in range(1, 8):
for axe_z in range(1, 8):
nom_percu = f"percu_{axe_y}_{axe_z}"
pos_y = axe_y * 1.15
pos_x = 5
pos_z = axe_z * 1.25pour créer un décalage et pas une grille rigide
if axe_z % 2 == 1:
pos_y += 1.0 # Ajuste cette valeur pour modifier l'écart
_ray = random.uniform(0.1, 0.4)
_percu((pos_x, pos_y, pos_z), _ray, nom_percu)pour creuser les percu dans le cube
_obj1 = bpy.data.objects["basse"]
_obj2 = bpy.data.objects[nom_percu]
boolean_boxes(_obj1, _obj2)étape 2 : définir une liste de sphères où se plaçeront les plateformes
spheres_valides = []définir une liste de coordonées pour les plateformes
platform_coords = []étape 3 : définir un nombre de sphères aplaties aléatoire entre (X,X) (dépend de la partie du morceau)
nappes_num = random.randint(
40,
50,
) # plus que le corps de la musique car de pluès en plus de clavier
print("Nombre de nappes : f[nappes_num]")créer des nappes de dimensions variables selon un rapport de 1/3 (si i est divisible par 3) 1 sphère sur 3 sera “petite”
for i in range(1, nappes_num):
if i % 3 == 0:
scale_xyz = (2, 2, 1)
else:
scale_xyz = (2.5, 2.5, 1.5)coordonées aléatoire dans le cube de 10*10 en laissant la possibilité de dépasser
pos_x = random.uniform(0.5, 9)
pos_y = random.uniform(0.5, 9)
pos_z = random.uniform(0, 11)définir la position des sphères selon les coordonées aléatoires en x,y,z
loc_nappe = (pos_x, pos_y, pos_z)nommer la nappe
nom_nappe = f"nappe_{i}"création de nappe à chaque boucle
_nappes(loc_nappe, scale_xyz, nom_nappe)étape 4: définir quel objet creuse lequel
_obj1 = bpy.data.objects["basse"]
_obj2 = bpy.data.objects[nom_nappe]chaque sphère creuse le cube à chaque boucle
boolean_boxes(_obj1, _obj2)informations utiles pour les étapes suivantes en lien avec les nappes
trop_proche = Falsedistance minimale entre les coordonées des centres en x et y pour les futurs plateformes
distance_min = 0.5étape 5: chance que les coordonées de la sphère soit gardée pour les plateformes pour que des plateformes ne soient ps trop proches les unes des autres
for platef in spheres_valides:
dist_x = loc_nappe[0] - platef["loc"][0]
dist_y = loc_nappe[1] - platef["loc"][1]
if math.sqrt(dist_x**2 + dist_y**2) < distance_min:
trop_proche = True
breaksi la distance est suffisante, elle est enregistrée dans les sphères qui peuvent donner une plateforme
if not trop_proche:
spheres_valides.append({"loc": loc_nappe, "scale": scale_xyz})
print("Cube creusé")fin de la base de la structure
création d’une liste qui stock toutes les futures plateformes
toutes_les_plateformes = []création d’une liste pour stocker les coordonnées des coins des cercles
coordonnees_coins_plateformes = []étape 1: les plateformes/notes de piano
for idx, data in enumerate(spheres_valides):
nom = f"Plateforme_{idx}"
loc_origine = data["loc"] # utilise data['loc'] pour savoir où la placer
taille_z_sphere = data["scale"][2] # on récupère la donnée en z des spheres
decalage_z = taille_z_sphere * -0.5 # on décale vers le basOn calcule la hauteur théorique de la plateforme pour savoir si elle sera en négative
z_theorique = loc_origine[2] + decalage_zOn pose une limite pour que z ne soit jamais inférieur à 0
z_final = max(z_theorique, 0)Nouvelle position de la plateforme
loc_decalee = (loc_origine[0], loc_origine[1], z_final)On force des dimensions à 1 (le rayon réel est défini par primitive_circle_add qui est à 0.7)
rayon_aléatoire = random.uniform(0.5, 1.5)
_dimension1 = rayon_aléatoire
_dimension2 = rayon_aléatoire
_dimension3 = rayon_aléatoireCrée la plateforme ronde
plat = _plateforme(loc_decalee, (_dimension1, _dimension2, _dimension3), nom)
toutes_les_plateformes.append(plat)récupère les sommets du cercle de la plateforme
bpy.context.view_layer.update()
sommets_globaux = [plat.matrix_world @ v.co for v in plat.data.vertices]On stocke tout avec une liste
coordonnees_coins_plateformes.append(
{
"nom_plateforme": nom,
"centre": loc_decalee,
"sommets": sommets_globaux,
"secteurs_utilises": [], # Stockera 'N', 'S', 'E', ou 'O'
},
)démarage de l’étape pour relier les plateformes entre elles
print("reliage des plateformes en cours")si le lein entre 2 plateformes est trop grand, pas de lien créé
distance_max_pont = 4.5
nb_plateformes = len(coordonnees_coins_plateformes)étape 1 : Les listes pour garder en mémoire les infos
compteur_connexions = [0] * nb_plateformesliste pour retenir les côtés déjà connectés pour chaque plateforme pour pas avoir 2 liens sur un même coté
cotes_utilises = [[] for _ in range(nb_plateformes)]liste qui vérifie la taille des chaînes pour ne pas avoir une grande chaine continue mais plusieurs petites chaines
groupes_connexions = [{k} for k in range(nb_plateformes)]liste tous les ponts
tous_les_ponts = []liste toutes les paires possibles
paires_possibles = []étape 2 : mise en place des conditions CDT° 1: traversabilité des murs On récupère notre objet “basse” (les murs) et on calcule sa matrice inverse.
obj_basse = bpy.data.objects.get("basse")
if obj_basse:
mat_inv = obj_basse.matrix_world.inverted()on limite l’angle possible pour les ponts entre les plateformes
angle_max_degres = (
35.0 # Ajuste la valeur de l'angle (35° max par rapport à l'horizontale)
)boucle pour comparer les plateformes entre elles
for i in range(nb_plateformes):
for j in range(i + 1, nb_plateformes):
plat_A = coordonnees_coins_plateformes[
i
] # On additionne les coordonnées de tous les sommets
plat_B = coordonnees_coins_plateformes[
j
] # On divise le total par le nombre de sommets (len(...)).Le résultat donne un point qui représente le centre exact de la plateforme.
centre_A = sum(plat_A["sommets"], Vector((0, 0, 0))) / len(plat_A["sommets"])
centre_B = sum(plat_B["sommets"], Vector((0, 0, 0))) / len(plat_B["sommets"])
distance = (centre_A - centre_B).length
if distance < distance_max_pont: dist_horizontale = math.sqrt(
(centre_A.x - centre_B.x) ** 2 + (centre_A.y - centre_B.y) ** 2,
)
delta_z = abs(centre_A.z - centre_B.z) # Différence de hauteurSi les cercles sont trop proches ou superposés vus du dessus, pont trop vertical donc pas à créer Comme le rayon est de 0.7, le diamètre combiné est de 1.4. On met une limite de sécurité à 1.8.
if dist_horizontale < 1.8:
continue # Rejet si les plateformes sont trop l'une sur l'autre pente_max_toleree = math.tan(math.radians(angle_max_degres))
if (
dist_horizontale == 0
or (delta_z / dist_horizontale) > pente_max_toleree
):
continue # Rejet si la pente dépasse l'angle max if dist_horizontale < 0.3 and delta_z > 0.2:
continue # On rejette immédiatement, c'est un pilier vertical ! if dist_horizontale > 0:
angle_radians = math.atan(delta_z / dist_horizontale)
angle_degres = math.degrees(angle_radians)
else:
angle_degres = 90.0 # Vertical purSi la pente est supérieure à l’angle max, on recommence
if angle_degres > angle_max_degres:
continue
print("fin du calcul d'angle")TEST RAYCAST pour verifier la traversabilité dans le cube des paroies (comme un laser qui traverse les paroies)
offset = Vector((0, 0, 0.2))
origin_ray = centre_A + offset
dest_ray = centre_B + offsetOn convertit ces points globaux en coordonnées “locales” pour l’objet basse
origin_local = mat_inv @ origin_ray
dest_local = mat_inv @ dest_ray
direction_local = (dest_local - origin_local).normalized()
distance_locale = (dest_local - origin_local).lengthtest ray_cast
hit, loc, normal, face_idx = obj_basse.ray_cast(
origin_local,
direction_local,
distance=distance_locale,
)Si hit est False, le laser n’a rien touché, le ponts peut se former sans toucher les mur.
if not hit:
paires_possibles.append((i, j, distance, centre_A, centre_B))On trie par distance
paires_possibles.sort(key=lambda x: x[2])On crée les ponts avec les nouvelles conditions de secteurs et de pente
for paire in paires_possibles:
i, j, distance, centre_A, centre_B = paireCDT° 2 : Taille de la chaîne
groupe_i = next(g for g in groupes_connexions if i in g)
groupe_j = next(g for g in groupes_connexions if j in g)
if groupe_i != groupe_j:
if (
len(groupe_i) + len(groupe_j) > 3
): # les chaines sont limitées à 3 plateformes max
continueCDT° 3 : Maximum 2 ponts par plateforme globale
if compteur_connexions[i] < 2 and compteur_connexions[j] < 2:
plat_A_data = coordonnees_coins_plateformes[i]
plat_B_data = coordonnees_coins_plateformes[j]
dist_horizontale = math.sqrt(
(centre_A.x - centre_B.x) ** 2 + (centre_A.y - centre_B.y) ** 2,
)
delta_z = abs(centre_A.z - centre_B.z)Si la pente (Hauteur / Distance au sol) est supérieure à la limite de l’angle max
pente_max_toleree = math.tan(math.radians(angle_max_degres))
if dist_horizontale == 0 or (delta_z / dist_horizontale) > pente_max_toleree:
continue # Rejet immédiat si c'est trop raide ou vertical purOn calcule l’angle directionnel du pont sur le plan horizontal X/Y
vecteur_AB = (centre_B - centre_A).normalized()
angle_AB = math.atan2(
vecteur_AB.y, vecteur_AB.x
) # Angle en radians de A vers B
angle_BA = math.atan2(-vecteur_AB.y, -vecteur_AB.x) # Angle de B vers AFonction interne pour attribuer un secteur (N, S, E, O) selon l’angle
def determiner_secteur(angle):
degres = math.degrees(angle) % 360
if 45 <= degres < 135:
return "N"
if 135 <= degres < 225:
return "O"
if 225 <= degres < 315:
return "S"
return "E"
secteur_A = determiner_secteur(angle_AB)
secteur_B = determiner_secteur(angle_BA)CDT° 4 : Vérification si le côté/secteur est déjà pris
if (
secteur_A in plat_A_data["secteurs_utilises"]
or secteur_B in plat_B_data["secteurs_utilises"]
):
continue # Ce côté de la plateforme est déjà occupé par un autre pont !création du maillage pour les ponts
nb_sommets_liaison = 6 # Le pont sera attaché à chaque plateforme en utilisant exactement 6 sommets
axe_pont = vecteur_AB
axe_perpendiculaire = Vector((-axe_pont.y, axe_pont.x, 0))Le code trie tous les sommets de la plateforme A selon leur distance par rapport au centre de la plateforme B il ne garde que les 6 sommets les plus proches de B
sommets_A_proches = sorted(
plat_A_data["sommets"],
key=lambda v: (v - centre_B).length,
)[:nb_sommets_liaison]
sommets_A_proches.sort(key=lambda v: v.dot(axe_perpendiculaire))pareil pour la plateforme B
sommets_B_proches = sorted(
plat_B_data["sommets"],
key=lambda v: (v - centre_A).length,
)[:nb_sommets_liaison]
sommets_B_proches.sort(key=lambda v: v.dot(axe_perpendiculaire))crée un nouveau maillage
nom_pont = f"Pont_{i}_vers_{j}"
mesh_pont = bpy.data.meshes.new(nom_pont)
obj_pont = bpy.data.objects.new(nom_pont, mesh_pont)
bpy.context.collection.objects.link(obj_pont)
verts_pydata = [(v.x, v.y, v.z) for v in sommets_A_proches] + [
(v.x, v.y, v.z) for v in sommets_B_proches
]La boucle va créer 5 faces quadrilatères (rectangles) pour relier les sommets en face à face
faces_pydata = []
for k in range(nb_sommets_liaison - 1):
v0 = k
v1 = k + 1
v2 = nb_sommets_liaison + k + 1
v3 = nb_sommets_liaison + k
faces_pydata.append((v0, v1, v2, v3))
mesh_pont.from_pydata(verts_pydata, [], faces_pydata)
mesh_pont.update()
tous_les_ponts.append(obj_pont)crée des compteurs pour calculer le nombre de connexions
plat_A_data["secteurs_utilises"].append(secteur_A)
plat_B_data["secteurs_utilises"].append(secteur_B)
compteur_connexions[i] += 1
compteur_connexions[j] += 1
if groupe_i != groupe_j:
groupe_i.update(groupe_j)
groupes_connexions.remove(groupe_j)print("Nettoyage des plateformes isolées...")On crée une copie de la liste pour éviter les bugs pendant qu’on supprime des éléments
plateformes_a_garder = []
for idx, plat in enumerate(toutes_les_plateformes):Si la plateforme n’a aucun pont (compteur à 0)
if compteur_connexions[idx] == 0:
nom_plat = plat.name
mesh_plat = plat.data bpy.data.objects.remove(plat, do_unlink=True) if mesh_plat:
bpy.data.meshes.remove(mesh_plat)
print(f"[{nom_plat}] supprimée (isolée)")
else:Si elle est connectée, on la garde dans la liste pour la fusion finale
plateformes_a_garder.append(plat)On met à jour la liste globale avec uniquement les plateformes valides
toutes_les_plateformes = plateformes_a_garderprint("Attribution des couleurs par chaînes entières...")mat_or = creer_materiau_hex(
"Mat_Or",
"#F4DC73FF",
) # couleur qui avait été choisie dans la vue 3D qui est reprise en language de base16
mat_turquoise = creer_materiau_hex("Mat_Turquoise", "#ABECF4FF")groupes_actifs = []
for groupe in groupes_connexions:
groupe_filtre = {idx for idx in groupe if compteur_connexions[idx] > 0}
if groupe_filtre: # Si le groupe n'est pas vide
groupes_actifs.append(groupe_filtre)total_groupes = len(groupes_actifs)
for g_idx, groupe in enumerate(groupes_actifs):Choix de la couleur pour toute la chaîne
mat_du_groupe = mat_or if g_idx < (total_groupes / 2) else mat_turquoise
for plat_idx in groupe:Couleur pour la plateforme
nom_plat = f"Plateforme_{plat_idx}"
plat_obj = bpy.data.objects.get(nom_plat)
if plat_obj:
assigner_materiau(plat_obj, mat_du_groupe)Colorer le pont associé à cette plateforme On cherche si un pont impliquant cet index existe
for j in range(nb_plateformes):
nom_pont_1 = f"Pont_{plat_idx}_vers_{j}"
nom_pont_2 = f"Pont_{j}_vers_{plat_idx}"
pont_obj = bpy.data.objects.get(nom_pont_1) or bpy.data.objects.get(
nom_pont_2
)
if pont_obj:
assigner_materiau(pont_obj, mat_du_groupe)bpy.ops.object.select_all(action="DESELECT")elements_a_fusionner = toutes_les_plateformes + tous_les_ponts
for obj in elements_a_fusionner:
if obj.name in bpy.context.scene.objects: # pour vérifier les objets sélectionnés
obj.select_set(True)if len(toutes_les_plateformes) > 0:
obj_global = toutes_les_plateformes[0]
bpy.context.view_layer.objects.active = obj_global
bpy.ops.object.join()
bpy.ops.object.mode_set(mode="OBJECT")Nettoyage BMesh
bm = bmesh.new()
bm.from_mesh(obj_global.data)
bmesh.ops.remove_doubles(bm, verts=bm.verts, dist=0.01)
bmesh.ops.recalc_face_normals(bm, faces=bm.faces)
bmesh.ops.triangulate(bm, faces=bm.faces)
bm.to_mesh(obj_global.data)
bm.free()
obj_global.data.update()
obj_global.name = "Structure_Plateformes"
obj_global.data.name = "Structure_Plateformes_mesh"Épaisseur Solidify, les chaines étaient encore plates, il faut leur donner de l’épaisseur
mod_solid_global = obj_global.modifiers.new(
name="Epaisseur_Globale", type="SOLIDIFY"
)
mod_solid_global.thickness = 0.15 # épaisseur
mod_solid_global.offset = -1
bpy.ops.object.modifier_apply(modifier=mod_solid_global.name)bpy.ops.object.select_all(action="SELECT")
bpy.ops.object.shade_auto_smooth()
bpy.ops.object.select_all(action="DESELECT")
print("Fini avec succès !")